summaryrefslogtreecommitdiff
path: root/src/routes/playlists/[id]/+page.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/playlists/[id]/+page.svelte')
-rw-r--r--src/routes/playlists/[id]/+page.svelte294
1 files changed, 294 insertions, 0 deletions
diff --git a/src/routes/playlists/[id]/+page.svelte b/src/routes/playlists/[id]/+page.svelte
new file mode 100644
index 0000000..21de34f
--- /dev/null
+++ b/src/routes/playlists/[id]/+page.svelte
@@ -0,0 +1,294 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { goto } from '$app/navigation';
+ import { playlists, type Playlist } from '$lib/stores/playlists';
+ import { formatDuration } from '$lib/api/youtube';
+
+ const playlistId = $derived($page.params.id ?? '');
+ const playlist = $derived(playlists.getById($playlists, playlistId));
+
+ let editing = $state(false);
+ let editName = $state('');
+
+ function startEditing() {
+ if (!playlist) return;
+ editName = playlist.name;
+ editing = true;
+ }
+
+ function saveEdit() {
+ if (!editName.trim() || !playlist) return;
+ playlists.rename(playlist.id, editName.trim());
+ editing = false;
+ }
+
+ function cancelEdit() {
+ editing = false;
+ editName = '';
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') saveEdit();
+ else if (e.key === 'Escape') cancelEdit();
+ }
+
+ function removeVideo(videoId: string) {
+ if (!playlist) return;
+ playlists.removeVideo(playlist.id, videoId);
+ }
+
+ async function deletePlaylist() {
+ if (!playlist) return;
+ if (confirm(`Delete playlist "${playlist.name}"?`)) {
+ await playlists.delete(playlist.id);
+ goto('/playlists');
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>{playlist?.name || 'Playlist'} - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ {#if !playlist}
+ <div class="error">Playlist not found</div>
+ {:else}
+ <div class="playlist-page">
+ <div class="playlist-header">
+ {#if editing}
+ <input
+ type="text"
+ bind:value={editName}
+ onkeydown={handleKeydown}
+ class="edit-input"
+ autofocus
+ />
+ <button class="btn btn-primary" onclick={saveEdit}>Save</button>
+ <button class="btn btn-secondary" onclick={cancelEdit}>Cancel</button>
+ {:else}
+ <h1 class="title">{playlist.name}</h1>
+ <div class="actions">
+ <button class="btn btn-secondary" onclick={startEditing}>Rename</button>
+ <button class="btn btn-danger" onclick={deletePlaylist}>Delete</button>
+ </div>
+ {/if}
+ </div>
+
+ <p class="meta">{playlist.videos.length} videos</p>
+
+ {#if playlist.videos.length === 0}
+ <div class="empty">
+ <p>This playlist is empty.</p>
+ <p>Add videos from the watch page using the "Save" button.</p>
+ </div>
+ {:else}
+ <div class="video-list">
+ {#each playlist.videos as video, index (video.videoId)}
+ <div class="video-item">
+ <span class="index">{index + 1}</span>
+ <a href="/watch/{video.videoId}" class="video-link">
+ <div class="thumbnail">
+ <img src={video.thumbnail} alt="" />
+ <span class="duration">{formatDuration(video.lengthSeconds)}</span>
+ </div>
+ <div class="info">
+ <h3 class="video-title">{video.title}</h3>
+ <span
+ class="author"
+ role="link"
+ tabindex="0"
+ onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${video.authorId}`; }}
+ onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${video.authorId}`; } }}
+ >
+ {video.author}
+ </span>
+ </div>
+ </a>
+ <button
+ class="remove-btn"
+ onclick={() => removeVideo(video.videoId)}
+ aria-label="Remove from playlist"
+ >
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
+ </svg>
+ </button>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .playlist-page {
+ max-width: 900px;
+ }
+
+ .playlist-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 0.5rem;
+ }
+
+ .title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0;
+ flex: 1;
+ }
+
+ .edit-input {
+ flex: 1;
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ }
+
+ .edit-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ }
+
+ .actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .meta {
+ color: var(--text-muted);
+ margin-bottom: 2rem;
+ }
+
+ .video-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .video-item {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.5rem;
+ border-radius: 8px;
+ transition: background 0.15s ease;
+ }
+
+ .video-item:hover {
+ background: var(--bg-hover);
+ }
+
+ .index {
+ width: 24px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ flex-shrink: 0;
+ }
+
+ .video-link {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex: 1;
+ text-decoration: none;
+ color: inherit;
+ min-width: 0;
+ }
+
+ .thumbnail {
+ position: relative;
+ width: 120px;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ overflow: hidden;
+ flex-shrink: 0;
+ }
+
+ .thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .duration {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ background: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ padding: 1px 4px;
+ border-radius: 2px;
+ font-size: 0.75rem;
+ }
+
+ .info {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .video-title {
+ font-size: 0.95rem;
+ font-weight: 500;
+ margin: 0 0 0.25rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .author {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ text-decoration: none;
+ cursor: pointer;
+ }
+
+ .author:hover {
+ color: var(--text-primary);
+ }
+
+ .remove-btn {
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s ease, background 0.15s ease;
+ flex-shrink: 0;
+ }
+
+ .video-item:hover .remove-btn {
+ opacity: 1;
+ }
+
+ .remove-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--error-color);
+ }
+
+ @media (max-width: 600px) {
+ .thumbnail {
+ width: 100px;
+ }
+
+ .index {
+ display: none;
+ }
+ }
+</style>